/*
 * Background Flickr: Copyright (C) 2009  Craig McNicholas
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.ddw.app.bflickr.imaging;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.Vector;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.http.util.ByteArrayBuffer;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;

import com.ddw.app.bflickr.flickr.api.utils.Website;
import com.ddw.app.bflickr.logging.Log;
import com.ddw.app.bflickr.logging.LogFactory;

/**
 * Class to handle collection of images over the network or locally
 * cached. Also gives methods to clear the cache or force a re-download. 
 * The method is asynchronous so a listener should be setup to return 
 * callbacks to the callee.
 * @author craig
 *
 */
public class ImageCollector {

	private Log log = LogFactory.createInstance(this.getClass());
	protected Vector<ImageCollectorListener> listeners = new Vector<ImageCollectorListener>();
	private boolean forceOverwrite = false;
	private boolean saveInCache = true;
	private String url = null;
	private Context context;
	
	/**
	 * Creates a new image collector instance
	 * @param context the applications context
	 * @param url the url of the image to collect
	 * @throws IllegalArgumentException if the context is null or the url is badly formed, <i>urls cannot be null, empty or invalid</i>
	 */
	public ImageCollector(Context context, String url) throws IllegalArgumentException {
		setContext(context);
		setUrl(url);
	}
	
	/**
	 * Getter of the flag indicating whether the request will ignore 
	 * a corresponding cache file and force a download of the image, <b>by default this is false
	 * @return a flag indicating whether the download will be forced
	 */
	public synchronized boolean isOverwriteEnabled() {
		return this.forceOverwrite;
	}
	
	/**
	 * Setter of the flag indicating whether the request will ignore 
	 * a corresponding cache file and force a download of the image, <b>by default this is false</b>
	 * @param enabled a flag indicating whether the download will be forced
	 */
	public synchronized void setOverwriteEnabled(boolean enabled) {
		this.forceOverwrite = enabled;
	}
	
	/**
	 * Getter of the flag indicating whether the request will cache 
	 * the image file if it is downloaded successfully, <b>by default this is true</b>
	 * @return a flag indicating whether the file will be cached
	 */
	public synchronized boolean getCacheFileOnDownload() {
		return this.saveInCache;
	}
	
	/**
	 * Setter of the flag indicating whether the request will cache 
	 * the image file if it is downloaded successfully, <b>by default this is true</b>
	 * @param enabled a flag indicating whether the file will be cached
	 */
	public synchronized void setCacheFileOnDownload(boolean enabled) {
		this.saveInCache = enabled;
	}
	
	/**
	 * Getter of the url to download the image from
	 * @return the url to download from
	 */
	public synchronized String getUrl() {
		return this.url;
	}
	
	/**
	 * Setter of the url to download the image from
	 * @param url the url to download from
	 * @throws IllegalArgumentException if the url is null, empty or badly formed
	 */
	public synchronized void setUrl(String url) throws IllegalArgumentException {
		if(url == null) {
			log.error("The url cannot be null");
			throw new IllegalArgumentException("The url cannot be null");
		}
		if(url.trim().length() == 0) {
			log.error("The url cannot be empty");
			throw new IllegalArgumentException("The url cannot be empty");
		}
		if(!Website.validateURL(url)) {
			log.error("The url was not in the correct format");
			throw new IllegalArgumentException("The url was not in the correct format");
		}
		this.url = url;
	}
	
	/**
	 * Setter of the applications context, used to obtain local files
	 * @param context the applications context
	 */
	private void setContext(Context context) {
		if(context == null) {
			log.error("The context cannot be null");
			throw new IllegalArgumentException("The context cannot be null");
		}
		this.context = context;
	}
	
	/**
	 * Adds a listener interface to this object, the callbacks are then 
	 * raised after the asynchronous start method is fired.
	 * @param listener the listener to add to this object
	 */
	public void addListener(ImageCollectorListener listener) {
		if(listener != null) {
			if(!this.listeners.contains(listener)) {
				this.listeners.add(listener);
			} else {
				log.debug("Listeners list already contains listener " + listener);
			}
		} else {
			log.warn("A null listener cannot be added");
		}
	}
	
	/**
	 * Removes a listener interface from this object
	 * @param listener the listener to remove from this object
	 */
	public void removeListener(ImageCollectorListener listener) {
		if(listener != null) {
			if(this.listeners.contains(listener)) {
				this.listeners.remove(listener);
			} else {
				log.debug("Listeners list does not contain listener " + listener);
			}
		} else {
			log.warn("A null listener cannot be added");
		}
	}
	
	/**
	 * Removes all attached listeners to this object
	 */
	public void clearListeners() {
		this.listeners.clear();
	}
	
	/**
	 * Starts the asynchronous download request, this method may trigger a
	 * response <u>very</u> quickly if the image is returned from the cache
	 * so be sure listeners are in place before calling start to avoid race 
	 * conditions.
	 */
	public void start() {
		new Thread(new Runnable() {

			@Override
			public void run() {
				try {
					String cacheName = createFriendlyName(getUrl());
					log.debug("Hashing url for cache, value is: " + cacheName);
					
					final boolean fromCache = fileArrayContains(context.getFilesDir().listFiles(), cacheName) && !isOverwriteEnabled();
					log.debug("The file " + (fromCache ? "WAS" : "WAS NOT") + " found in the cache" + 
							(isOverwriteEnabled() ? " BUT will be overwritten" : ""));
					
					try {
						Bitmap image = collectBitmap(cacheName, fromCache);
						if(!fromCache && getCacheFileOnDownload()) {
							cacheFile(cacheName, image);
						}
				        raiseOnDownloadCompleted(image, fromCache);
					} catch(Throwable ex) {
			    		log.error("There was an error streaming from the requested source.", ex);
						raiseOnDownloadError(ex);
					}
				} catch(Throwable ex) {
		    		log.error("There was an error reading from the cache.", ex);
					raiseOnCacheError(ex);
				}
			}
			
		}).start();
	}
	
	/**
	 * Method to check a file listing array and return a flag indicating if the file
	 * name was found.
	 * @param files the files to search
	 * @param value the value to search for
	 * @return flag indicating if the value was found
	 */
	private boolean fileArrayContains(File files[], String value) {
		if(files != null) {
			for(File file : files) {
				if(file.getName().compareToIgnoreCase(value) == 0) {
					return true;
				}
			}
		}
		return false;
	}
	
	/**
	 * The expression to validate friendly file names against. Making it final and static 
	 * provides performance benefits.
	 */
	private final static String expression = "[^A-Za-z0-9]";
	
	/**
	 * The compiled patten to validate friendly file name. Making it final and static 
	 * provides performance benefits by allowing the validator to not have to compile costly 
	 * expressions over and over.
	 */
	private final static Pattern pattern = Pattern.compile(expression, Pattern.CASE_INSENSITIVE);
	
	/**
	 * The pattern matcher used to validate a friendly file name
	 * provides performance benefits by allowing the validator to reuse the matcher.
	 */
	private static Matcher matcher = null;
	
	/**
	 * Converts a string value to a file friendly name
	 * @param input the value to convert
	 * @return the friendly name
	 */
	private String createFriendlyName(String input) {
		if(matcher == null) {
			matcher = pattern.matcher(input);
		} else { // Reuse matcher if instance is valid
			matcher.reset(input);
		}
		return matcher.replaceAll(""); // Replace with empty string
	}
	
	/**
	 * Triggers the download error callback for all listeners 
	 * attached to this object.
	 * @param throwable the thrown exception
	 */
	private void raiseOnDownloadError(Throwable throwable) {
		for(ImageCollectorListener listener : this.listeners) {
			if(listener != null) {
				listener.onDownloadError(throwable);
			}
		}
	}
	
	/**
	 * Triggers the cache error callback for all listeners 
	 * attached to this object.
	 * @param throwable the thrown exception
	 */
	private void raiseOnCacheError(Throwable throwable) {
		for(ImageCollectorListener listener : this.listeners) {
			if(listener != null) {
				listener.onDownloadError(throwable);
			}
		}
	}
	
	/**
	 * Triggers the download completed callback for all listeners 
	 * attached to this object.
	 * @param image the image that was downloaded or retrieved from 
	 * the cache
	 * @param fromCache flag indicating whether the file was loaded 
	 * from the cache
	 */
	private void raiseOnDownloadCompleted(Bitmap image, boolean fromCache) {
		for(ImageCollectorListener listener : this.listeners) {
			if(listener != null) {
				listener.onDownloadCompleted(image, fromCache);
			}
		}
	}

	/**
	 * Method to obtain the requested file from the url or if it is available, in the cache.
	 * @param fileName the filename to search for in the cache
	 * @param fromCache flag indicating if the file was found in the cache
	 * @return the bitmap object or null
	 * @throws Throwable if there is an error accessing the cache or stream
	 */
	private Bitmap collectBitmap(String fileName, final boolean fromCache) throws Throwable {
		Bitmap bmp = getBitmapFromStream(fileName, fromCache);
		if(bmp != null) {
			return bmp;
		} else {
			log.error("The resource was not valid or could not be converted to a bitmap.");
			throw new Throwable("The resource was not valid or could not be converted to a bitmap.");
		}
	}

	/**
	 * Method to collect the bitmap from an input stream
	 * @param fileName the filename to search for in the cache
	 * @param fromCache flag indicating if the file was found in the cache
	 * @return a decoded bitmap or null if failed
	 * @throws Exception if there is an error reading from the buffered stream
	 * @throws IOException if there is an error reading from the stream
	 */
	private Bitmap getBitmapFromStream(String fileName, final boolean fromCache) throws Exception, IOException {
		InputStream in = null;
		Bitmap bmp = null;
		try {
			in = getInputStream(fileName, fromCache);
			BufferedInputStream bin = null;
			try {
				bin = new BufferedInputStream(in);
				bmp =  BitmapFactory.decodeStream(bin);
			} catch(Exception ex) {
				log.error("There was an error opening or reading from the buffered input stream", ex);
				throw ex;
			} finally {
				if(bin != null) {
					bin.close();
				}
			}
			try {
				if(!getCacheFileOnDownload()) {
					log.debug("Deleting the cached file");
					this.context.deleteFile(fileName);
				}
			} catch(Exception ex) {
				log.error("There was an error deleting the cached file");
				throw ex;
			}
		} catch(IOException ex) {
			log.error("There was an error opening or reading from the stream", ex);
			throw ex;
		} finally {
			log.debug("Closing stream");
			if(in != null) {
				in.close();
			}
		}
		return bmp;
	}

	/**
	 * Method to obtain the input stream from either the cache or a url.
	 * If it is from a url the file is downloaded and cached then opened.
	 * @param fileName the name of the cache file
	 * @param fromCache whether to load from the cache
	 * @return the input stream to the bitmap object
	 * @throws Exception if there is a problem downloading the image or opening
	 * the local cache item
	 */
	private InputStream getInputStream(String fileName, final boolean fromCache) throws Exception {
		FileInputStream stream = null;
		if(!fromCache) {
			downloadImageToCache(fileName);
		}
		log.debug("Obtaining input stream from cache file.");
		try {
			stream = this.context.openFileInput(fileName);
		} catch(Exception ex) {
			log.error("Error opening a stream to the local file", ex);
			throw ex;
		}
		return stream;
	}

	/**
	 * @param fileName
	 * @throws Exception 
	 */
	private void downloadImageToCache(String fileName) throws Exception {
		log.debug("Obtaining input stream from remote url at: " + getUrl());
		final URL url = new URL(getUrl());
		final URLConnection cnn = url.openConnection();
		InputStream in;
		ByteArrayBuffer bytes = null;
		in = cnn.getInputStream();
		bytes = readBytesFromInputStream(in);
		saveBytesToCache(fileName, bytes);
	}

	/**
	 * Method to cache an images byte array
	 * @param fileName the cache file name to use
	 * @param bytes the byte array buffer to output to the file
	 * @throws Exception if there is a problem saving the file
	 */
	private void saveBytesToCache(String fileName, ByteArrayBuffer bytes) throws Exception {
		FileOutputStream output = null;
		try {
			output = this.context.openFileOutput(fileName, Context.MODE_PRIVATE);
			output.write(bytes.toByteArray());
		} catch(Exception ex) {
			log.error("Could not cache image bytes", ex);
			throw ex;
		} finally {
			if(output != null) {
				output.flush();
				output.close();
			}
		}
	}

	/**
	 * Method to read bytes from an input stream into a byte array buffer
	 * @param in the input stream to read from
	 * @return a byte array buffer containing the input bytes
	 * @throws IOException if theres a problem reading from the stream
	 */
	private ByteArrayBuffer readBytesFromInputStream(InputStream in) throws IOException {
		final int bufferSize = 1024;
		ByteArrayBuffer bytes = new ByteArrayBuffer(bufferSize);
		byte buffer[] = new byte[bufferSize];
		int read = 0;
		try {
			while((read = in.read(buffer)) != -1) {
				bytes.append(buffer, 0, read);
			}
			in.close();
		} catch(Exception ex) {
			log.error("Error reading from the input stream to the buffer", ex);
		}
		return bytes;
	}

	/**
	 * Method to cache a bitmap
	 * @param fileName the file name to cache the bitmap as
	 * @param bmp the bitmap to cache
	 * @throws IOException if there is an error writing the file
	 */
	private void cacheFile(String fileName, Bitmap bmp) throws IOException {
		FileOutputStream cache = null;
		try {
			log.debug("Creating cache file.");
			cache = this.context.openFileOutput(fileName, Context.MODE_PRIVATE);
			bmp.compress(Bitmap.CompressFormat.JPEG, 100, cache);
			cache.flush();
			log.debug("Completed caching file");
		} catch(Throwable ex) {
			log.error("The cache result failed to be cached.", ex);
			raiseOnCacheError(ex);
		} finally {
			log.debug("Closing cache file stream");
			if(cache != null) {
				cache.close();
			}
		}
	}
	
}
